BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: breakpoint adjustment

+203 -189
+5
CHANGELOG.md
··· 2 2 3 3 ## v0.1.0 - Unreleased 4 4 5 + ### 2026-04-08 6 + 7 + - Forward/Back history navigation in the app rail/navigation, and thread drawer 8 + - Added theme control (light/dark/system) with proper light theme support 9 + 5 10 ### 2026-04-07 6 11 7 12 - Image (single or gallery) & video player with blob downloading
+5 -5
src/App.tsx
··· 18 18 import { AppRail } from "./components/rail/AppRail"; 19 19 import { SessionSpotlight } from "./components/Session"; 20 20 import { ErrorToast } from "./components/shared/ErrorToast"; 21 - import { AppPreferencesProvider } from "./contexts/app-preferences"; 22 21 import { ThemeController } from "./components/theme/ThemeController"; 22 + import { AppPreferencesProvider } from "./contexts/app-preferences"; 23 23 import { AppSessionProvider, useAppSession } from "./contexts/app-session"; 24 24 import { AppShellUiProvider, useAppShellUi } from "./contexts/app-shell-ui"; 25 25 import { AppRouter } from "./router"; ··· 54 54 return ( 55 55 <> 56 56 <main 57 - class="grid h-screen min-h-screen overflow-hidden grid-cols-(--app-rail-cols) transition-[grid-template-columns] duration-300 ease-out max-[1180px]:h-auto max-[1180px]:min-h-screen max-[1180px]:grid-cols-1 max-[1180px]:overflow-visible" 57 + class="grid h-screen min-h-screen overflow-hidden grid-cols-(--app-rail-cols) transition-[grid-template-columns] duration-300 ease-out max-lg:h-auto max-lg:min-h-screen max-lg:grid-cols-1 max-lg:overflow-visible" 58 58 style={{ "--app-rail-cols": shell.railColumns }}> 59 59 <AppRail /> 60 60 61 61 <section 62 - class="grid min-h-0 overflow-hidden bg-surface max-[1180px]:min-h-[calc(100vh-4.75rem)] max-[1180px]:overflow-visible" 62 + class="grid min-h-0 overflow-hidden bg-surface max-lg:min-h-[calc(100vh-4.75rem)] max-lg:overflow-visible" 63 63 classList={{ 64 - "m-5 gap-6 rounded-2xl p-6 shadow-[0_24px_40px_rgba(125,175,255,0.05)] max-[1360px]:p-6 max-[1180px]:m-0 max-[1180px]:rounded-none max-[1180px]:p-5 max-[900px]:gap-5 max-[900px]:p-4 max-[640px]:gap-4 max-[640px]:p-3": 64 + "m-5 gap-6 rounded-2xl p-6 shadow-[0_24px_40px_rgba(125,175,255,0.05)] max-xl:p-6 max-lg:m-0 max-lg:rounded-none max-lg:p-5 max-md:gap-5 max-md:p-4 max-sm:gap-4 max-sm:p-3": 65 65 !props.fullWidth, 66 - "max-[1180px]:m-0 max-[1180px]:rounded-none": props.fullWidth, 66 + "max-lg:m-0 max-lg:rounded-none": props.fullWidth, 67 67 }} 68 68 aria-busy={session.bootstrapping}> 69 69 {props.children}
+4 -4
src/components/account/AccountSwitcher.tsx
··· 69 69 70 70 return ( 71 71 <div 72 - class="relative w-full transition-[width,max-width] duration-300 ease-out max-[1180px]:max-w-none" 72 + class="relative w-full transition-[width,max-width] duration-300 ease-out max-lg:max-w-none" 73 73 classList={{ 74 74 "z-40": shell.showSwitcher, 75 75 "w-auto": compact(), 76 - "max-[1180px]:col-start-5 max-[1180px]:row-start-1 max-[1180px]:justify-self-end": shell.narrowViewport, 77 - "max-[1180px]:col-span-full max-[1180px]:justify-self-stretch": !shell.narrowViewport, 76 + "max-lg:col-start-5 max-lg:row-start-1 max-lg:justify-self-end": shell.narrowViewport, 77 + "max-lg:col-span-full max-lg:justify-self-stretch": !shell.narrowViewport, 78 78 }} 79 79 ref={(element) => { 80 80 container = element; ··· 108 108 109 109 <Show when={shell.showSwitcher}> 110 110 <div 111 - class="ui-overlay-card absolute z-50 rounded-2xl border ui-outline-subtle bg-surface-container-highest p-4 backdrop-blur-[20px] max-[1180px]:bottom-auto max-[1180px]:top-[calc(100%+0.75rem)]" 111 + class="ui-overlay-card absolute z-50 rounded-2xl border ui-outline-subtle bg-surface-container-highest p-4 backdrop-blur-[20px] max-lg:bottom-auto max-lg:top-[calc(100%+0.75rem)]" 112 112 classList={{ 113 113 "inset-x-0 bottom-[calc(100%+0.75rem)]": !compact(), 114 114 "bottom-0 left-[calc(100%+0.85rem)] w-[19rem]": compact() && !shell.narrowViewport,
+1 -1
src/components/deck/DeckWorkspace.tsx
··· 372 372 }); 373 373 374 374 return ( 375 - <div class="relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden px-6 py-5 max-[900px]:px-4 max-[900px]:py-4 max-[640px]:px-3 max-[640px]:py-3"> 375 + <div class="relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden px-6 py-5 max-md:px-4 max-md:py-4 max-sm:px-3 max-sm:py-3"> 376 376 <DeckToolbar columnCount={state.columns.length} onAdd={() => setState("addPanelOpen", true)} /> 377 377 378 378 <div class="min-h-0 flex-1 overflow-x-auto overflow-y-hidden overscroll-contain">
+3 -3
src/components/feeds/FeedComposer.tsx
··· 32 32 export function ComposerLauncher(props: { activeAvatar?: string | null; activeHandle: string; onCompose: () => void }) { 33 33 return ( 34 34 <button 35 - class="tone-muted mb-4 flex w-full min-w-0 items-center gap-3 rounded-3xl border-0 px-4 py-4 text-left text-on-surface-variant shadow-[var(--inset-shadow)] transition duration-150 ease-out hover:bg-surface-bright max-[760px]:gap-2 max-[760px]:px-3.5 max-[520px]:py-3.5" 35 + class="tone-muted mb-4 flex w-full min-w-0 items-center gap-3 rounded-3xl border-0 px-4 py-4 text-left text-on-surface-variant shadow-(--inset-shadow) transition duration-150 ease-out hover:bg-surface-bright max-[760px]:gap-2 max-[760px]:px-3.5 max-[520px]:py-3.5" 36 36 type="button" 37 37 onClick={() => props.onCompose()}> 38 38 <ComposerIdentityAvatar ··· 135 135 136 136 function getComposerViewportClass(layout: ComposerSurfaceProps["layout"]) { 137 137 if (layout === "window") { 138 - return "mx-auto flex min-h-screen w-full max-w-4xl items-center justify-center p-6 max-[640px]:p-4"; 138 + return "mx-auto flex min-h-screen w-full max-w-4xl items-center justify-center p-6 max-sm:p-4"; 139 139 } 140 140 141 141 return "relative z-10 flex min-h-screen items-center justify-center p-4 pt-16"; ··· 265 265 ) { 266 266 return ( 267 267 <div class="min-h-0 overflow-y-auto overscroll-contain p-6"> 268 - <div class="flex gap-4 max-[640px]:flex-col"> 268 + <div class="flex gap-4 max-sm:flex-col"> 269 269 <ComposerAvatar activeAvatar={props.activeAvatar} activeHandle={props.activeHandle} /> 270 270 <div class="min-w-0 flex-1"> 271 271 <ComposerContexts
+1 -1
src/components/feeds/FeedPane.tsx
··· 74 74 }, 75 75 ) { 76 76 return ( 77 - <div class="flex min-w-0 items-start justify-between gap-4 max-[960px]:flex-col max-[960px]:items-stretch max-[900px]:gap-3"> 77 + <div class="flex min-w-0 items-start justify-between gap-4 max-[960px]:flex-col max-[960px]:items-stretch max-md:gap-3"> 78 78 <div class="min-w-0"> 79 79 <p class="m-0 text-xl font-semibold tracking-tight text-on-surface">Timeline</p> 80 80 <p class="mt-1 wrap-break-word text-xs uppercase tracking-[0.12em] text-on-surface-variant">
+3 -3
src/components/feeds/FeedTabs.tsx
··· 27 27 </For> 28 28 </div> 29 29 <button 30 - class="inline-flex h-11 shrink-0 items-center gap-2 rounded-full border-0 bg-white/5 px-4 text-sm text-on-surface transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8 max-[1040px]:px-3 max-[1040px]:text-xs max-[900px]:w-11 max-[900px]:justify-center max-[900px]:px-0" 30 + class="inline-flex h-11 shrink-0 items-center gap-2 rounded-full border-0 bg-white/5 px-4 text-sm text-on-surface transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8 max-[1040px]:px-3 max-[1040px]:text-xs max-md:w-11 max-md:justify-center max-md:px-0" 31 31 type="button" 32 32 onClick={() => props.onToggleDrawer()}> 33 33 <Icon aria-hidden="true" iconClass="i-ri-stack-line" /> 34 - <span class="max-[900px]:hidden">Saved feeds</span> 34 + <span class="max-md:hidden">Saved feeds</span> 35 35 </button> 36 36 </div> 37 37 ); ··· 49 49 type="button" 50 50 onClick={() => props.onSelect(props.feed.id)}> 51 51 <FeedChipAvatar feed={props.feed} generator={props.generator} /> 52 - <span class="max-w-44 truncate max-[900px]:max-w-36 max-[720px]:max-w-30"> 52 + <span class="max-w-44 truncate max-md:max-w-36 max-[720px]:max-w-30"> 53 53 {getFeedName(props.feed, props.generator?.displayName)} 54 54 </span> 55 55 </button>
+1 -1
src/components/feeds/FeedWorkspace.tsx
··· 31 31 32 32 return ( 33 33 <> 34 - <div class="grid h-full min-h-0 min-w-0 gap-6 xl:grid-cols-[minmax(0,1fr)_20rem] max-[1180px]:gap-5 max-[900px]:gap-4"> 34 + <div class="grid h-full min-h-0 min-w-0 gap-6 xl:grid-cols-[minmax(0,1fr)_20rem] max-lg:gap-5 max-md:gap-4"> 35 35 <FeedPane controller={controller} /> 36 36 37 37 <FeedWorkspaceSidebar
+4 -4
src/components/feeds/FeedWorkspaceSidebar.tsx
··· 51 51 ) { 52 52 return ( 53 53 <button 54 - class="tone-muted flex w-full items-center gap-3 rounded-1xl border-0 px-3 py-3 text-left text-on-surface shadow-[var(--inset-shadow)] transition duration-150 ease-out hover:-translate-y-px hover:bg-surface-bright" 54 + class="tone-muted flex w-full items-center gap-3 rounded-1xl border-0 px-3 py-3 text-left text-on-surface shadow-(--inset-shadow) transition duration-150 ease-out hover:-translate-y-px hover:bg-surface-bright" 55 55 type="button" 56 56 onClick={() => props.onSelect(props.feed.id)}> 57 57 <FeedChipAvatar feed={props.feed} generator={props.generator} /> ··· 132 132 133 133 function SidebarCard(props: ParentProps & { subtitle: string; title: string }) { 134 134 return ( 135 - <section class="tone-muted rounded-3xl p-4 shadow-[var(--inset-shadow)]"> 135 + <section class="tone-muted rounded-3xl p-4 shadow-(--inset-shadow)"> 136 136 <p class="m-0 text-base font-semibold text-on-surface">{props.title}</p> 137 137 <p class="mt-1 text-xs uppercase tracking-[0.12em] text-on-surface-variant">{props.subtitle}</p> 138 138 <div class="mt-4">{props.children}</div> ··· 142 142 143 143 function ToggleRow(props: { checked: boolean; label: string; onChange: (checked: boolean) => void }) { 144 144 return ( 145 - <label class="tone-muted flex items-center justify-between gap-3 rounded-2xl px-3 py-3 text-sm text-on-surface shadow-[var(--inset-shadow)]"> 145 + <label class="tone-muted flex items-center justify-between gap-3 rounded-2xl px-3 py-3 text-sm text-on-surface shadow-(--inset-shadow)"> 146 146 <span>{props.label}</span> 147 147 <input checked={props.checked} type="checkbox" onInput={(event) => props.onChange(event.currentTarget.checked)} /> 148 148 </label> ··· 151 151 152 152 function ShortcutLine(props: { keys: string; label: string }) { 153 153 return ( 154 - <div class="tone-muted flex items-center justify-between gap-3 rounded-2xl px-3 py-2.5 shadow-[var(--inset-shadow)]"> 154 + <div class="tone-muted flex items-center justify-between gap-3 rounded-2xl px-3 py-2.5 shadow-(--inset-shadow)"> 155 155 <span>{props.label}</span> 156 156 <span class="ui-input-strong rounded-full px-2 py-1 text-[0.68rem] uppercase tracking-[0.08em] text-primary"> 157 157 {props.keys}
+1 -1
src/components/feeds/PostCard.tsx
··· 534 534 return ( 535 535 <article 536 536 ref={(element) => view.registerRef?.(element)} 537 - class="tone-muted group min-w-0 overflow-hidden rounded-3xl px-4 py-4 shadow-[var(--inset-shadow)] transition duration-150 ease-out hover:bg-surface-bright max-[760px]:px-3.5 max-[760px]:py-3.5 max-[520px]:rounded-3xl max-[520px]:px-3 max-[520px]:py-3" 537 + class="tone-muted group min-w-0 overflow-hidden rounded-3xl px-4 py-4 shadow-(--inset-shadow) transition duration-150 ease-out hover:bg-surface-bright max-[760px]:px-3.5 max-[760px]:py-3.5 max-[520px]:rounded-3xl max-[520px]:px-3 max-[520px]:py-3" 538 538 classList={{ 539 539 "bg-[linear-gradient(135deg,rgba(125,175,255,0.11),rgba(0,115,222,0.06))] shadow-[inset_0_0_0_1px_rgba(125,175,255,0.22),0_0_0_1px_rgba(125,175,255,0.08)]": 540 540 !!view.focused,
+6 -11
src/components/posts/PostEngagementPanel.tsx
··· 121 121 } 122 122 123 123 return ( 124 - <section class="grid min-h-0 grid-rows-[auto_auto_minmax(0,1fr)] overflow-hidden rounded-4xl bg-surface-container shadow-[var(--inset-shadow)]"> 124 + <section class="grid min-h-0 grid-rows-[auto_auto_minmax(0,1fr)] overflow-hidden rounded-4xl bg-surface-container shadow-(--inset-shadow)"> 125 125 <header class="sticky top-0 z-20 flex items-center justify-between gap-3 bg-surface-container-high px-6 pb-4 pt-5 backdrop-blur-[18px] shadow-[inset_0_-1px_0_var(--outline-subtle)] max-[760px]:px-4 max-[520px]:px-3"> 126 126 <div class="min-w-0"> 127 127 <p class="m-0 text-xl font-semibold tracking-tight text-on-surface">Post Engagement</p> ··· 232 232 return ( 233 233 <button 234 234 type="button" 235 - class="tone-muted flex w-full items-start gap-3 rounded-3xl border-0 p-4 text-left shadow-[var(--inset-shadow)] transition duration-150 hover:bg-surface-bright disabled:cursor-default disabled:hover:bg-[var(--panel-muted)]" 235 + class="tone-muted flex w-full items-start gap-3 rounded-3xl border-0 p-4 text-left shadow-(--inset-shadow) transition duration-150 hover:bg-surface-bright disabled:cursor-default disabled:hover:bg-panel-muted" 236 236 disabled={!interactive()} 237 237 onClick={() => { 238 238 if (quoteInteractive()) { ··· 252 252 <p class="m-0 text-sm font-medium text-on-surface">{actorLabel()}</p> 253 253 <Show when={props.item.collection}> 254 254 {(collection) => ( 255 - <span class="tone-muted rounded-full px-2.5 py-1 text-xs text-on-surface-variant shadow-[var(--inset-shadow)]"> 255 + <span class="tone-muted rounded-full px-2.5 py-1 text-xs text-on-surface-variant shadow-(--inset-shadow)"> 256 256 {collection()} 257 257 </span> 258 258 )} ··· 267 267 <div class="mt-2"> 268 268 <QuotedPostPreview 269 269 author={quoteAuthor()} 270 - class="ui-input-strong rounded-2xl p-3 shadow-[var(--inset-shadow)]" 270 + class="ui-input-strong rounded-2xl p-3 shadow-(--inset-shadow)" 271 271 text={quoteText() ?? ""} 272 272 title="Quoted post" 273 273 truncate /> ··· 299 299 return null; 300 300 } 301 301 302 - return { 303 - did, 304 - handle, 305 - avatar: item.profile?.avatar ?? null, 306 - displayName: item.profile?.displayName ?? null, 307 - }; 302 + return { did, handle, avatar: item.profile?.avatar ?? null, displayName: item.profile?.displayName ?? null }; 308 303 } 309 304 310 305 function PanelMessage(props: { body: string; title: string }) { ··· 323 318 <div class="grid gap-3"> 324 319 <For each={Array.from({ length: 4 })}> 325 320 {() => ( 326 - <div class="tone-muted rounded-3xl p-5 shadow-[var(--inset-shadow)]"> 321 + <div class="tone-muted rounded-3xl p-5 shadow-(--inset-shadow)"> 327 322 <div class="flex gap-3"> 328 323 <div class="skeleton-block h-11 w-11 rounded-full" /> 329 324 <div class="grid min-w-0 flex-1 gap-2">
+5 -5
src/components/posts/PostPanel.tsx
··· 126 126 } 127 127 128 128 return ( 129 - <section class="grid min-h-0 grid-rows-[auto_minmax(0,1fr)] overflow-hidden rounded-4xl bg-surface-container shadow-[var(--inset-shadow)]"> 129 + <section class="grid min-h-0 grid-rows-[auto_minmax(0,1fr)] overflow-hidden rounded-4xl bg-surface-container shadow-(--inset-shadow)"> 130 130 <header class="sticky top-0 z-20 flex items-center justify-between gap-3 bg-surface-container-high px-6 pb-4 pt-5 backdrop-blur-[18px] shadow-[inset_0_-1px_0_var(--outline-subtle)] max-[760px]:px-4 max-[520px]:px-3"> 131 131 <div class="min-w-0"> 132 132 <p class="m-0 text-xl font-semibold tracking-tight text-on-surface">Post</p> ··· 206 206 <div class="grid gap-3"> 207 207 <For each={props.parentChain}> 208 208 {(parent) => ( 209 - <div class="tone-muted rounded-3xl p-3 shadow-[var(--inset-shadow)]"> 209 + <div class="tone-muted rounded-3xl p-3 shadow-(--inset-shadow)"> 210 210 <PostCard 211 211 bookmarkPending={!!props.bookmarkPendingByUri[parent.post.uri]} 212 212 likePending={!!props.likePendingByUri[parent.post.uri]} ··· 235 235 repostPending={!!props.repostPendingByUri[focused().post.uri]} /> 236 236 237 237 <Show when={focused().replies?.length}> 238 - <div class="tone-muted grid gap-3 rounded-3xl p-3 shadow-[var(--inset-shadow)]"> 238 + <div class="tone-muted grid gap-3 rounded-3xl p-3 shadow-(--inset-shadow)"> 239 239 <For each={focused().replies}> 240 240 {(reply) => ( 241 241 <ThreadReplies ··· 334 334 335 335 function SkeletonPostCard() { 336 336 return ( 337 - <div class="tone-muted rounded-3xl p-5 shadow-[var(--inset-shadow)]"> 337 + <div class="tone-muted rounded-3xl p-5 shadow-(--inset-shadow)"> 338 338 <div class="flex gap-3"> 339 339 <div class="skeleton-block h-11 w-11 rounded-full" /> 340 340 <div class="min-w-0 flex-1"> ··· 352 352 353 353 function StateCard(props: { label: string; meta: string }) { 354 354 return ( 355 - <div class="tone-muted rounded-3xl p-4 shadow-[var(--inset-shadow)]"> 355 + <div class="tone-muted rounded-3xl p-4 shadow-(--inset-shadow)"> 356 356 <p class="m-0 text-sm font-semibold text-on-surface">{props.label}</p> 357 357 <p class="mt-1 text-xs text-on-surface-variant">{props.meta}</p> 358 358 </div>
+2
src/components/posts/ThreadDrawer.test.tsx
··· 89 89 )); 90 90 91 91 expect(await screen.findByText("Thread root")).toBeInTheDocument(); 92 + expect(screen.getByRole("button", { name: "Back" })).toBeDisabled(); 93 + expect(screen.getByRole("button", { name: "Forward" })).toBeDisabled(); 92 94 93 95 fireEvent.click(screen.getByRole("button", { name: "Close thread" })); 94 96
+27 -12
src/components/posts/ThreadDrawer.tsx
··· 2 2 import { useAppSession } from "$/contexts/app-session"; 3 3 import { FeedController } from "$/lib/api/feeds"; 4 4 import { findRootPost, isBlockedNode, isNotFoundNode, isThreadViewPost, patchThreadNode } from "$/lib/feeds"; 5 + import { useNavigationHistory } from "$/lib/navigation-history"; 5 6 import type { PostView, ThreadNode } from "$/lib/types"; 6 - import { createEffect, createMemo, For, Match, onCleanup, Show, Switch } from "solid-js"; 7 + import { createEffect, createMemo, For, Match, onCleanup, Show, splitProps, Switch } from "solid-js"; 7 8 import { createStore } from "solid-js/store"; 8 9 import { Motion, Presence } from "solid-motionone"; 9 10 import { PostCard } from "../feeds/PostCard"; 11 + import { HistoryControls } from "../shared/HistoryControls"; 10 12 import { usePostInteractions } from "./usePostInteractions"; 11 13 import { usePostNavigation } from "./usePostNavigation"; 12 14 import { useThreadOverlayNavigation } from "./useThreadOverlayNavigation"; ··· 72 74 const session = useAppSession(); 73 75 const postNavigation = usePostNavigation(); 74 76 const threadOverlay = useThreadOverlayNavigation(); 77 + const history = useNavigationHistory(); 75 78 const [state, setState] = createStore<ThreadDrawerState>(createThreadDrawerState()); 76 79 const activeUri = createMemo(() => (threadOverlay.drawerEnabled() ? threadOverlay.threadUri() : null)); 77 80 const rootPost = createMemo(() => findRootPost(state.thread)); ··· 157 160 transition={{ duration: 0.22 }}> 158 161 <ThreadDrawerHeader 159 162 activeUri={activeUri()} 163 + canGoBack={history.canGoBack()} 164 + canGoForward={history.canGoForward()} 165 + onGoBack={history.goBack} 166 + onGoForward={history.goForward} 160 167 onMaximize={(uri) => void postNavigation.openPostScreen(uri)} 161 168 parentThreadHref={parentThreadHref()} 162 169 onClose={() => void threadOverlay.closeThread()} /> ··· 235 242 function ThreadDrawerHeader( 236 243 props: { 237 244 activeUri: string | null; 245 + canGoBack: boolean; 246 + canGoForward: boolean; 238 247 onClose: () => void; 248 + onGoBack: () => void; 249 + onGoForward: () => void; 239 250 onMaximize: (uri: string) => void; 240 251 parentThreadHref: string | null; 241 252 }, 242 253 ) { 254 + const [local, historyControls] = splitProps(props, ["parentThreadHref", "activeUri", "onClose", "onMaximize"]); 243 255 return ( 244 - <header class="sticky top-0 z-10 mb-4 flex items-center justify-between rounded-3xl bg-surface-container-high px-4 py-3 shadow-[var(--inset-shadow)]"> 245 - <div> 256 + <header class="sticky top-0 z-10 mb-4 flex items-center gap-3 rounded-3xl bg-surface-container-high px-4 py-3 shadow-(--inset-shadow)"> 257 + <div class="min-w-0 flex-1"> 246 258 <p class="m-0 text-base font-semibold text-on-surface">Thread</p> 247 - <Show when={props.parentThreadHref}> 259 + <Show when={local.parentThreadHref}> 248 260 {(href) => ( 249 261 <a 250 262 class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant no-underline transition hover:text-primary hover:underline" ··· 254 266 )} 255 267 </Show> 256 268 </div> 257 - <div class="flex items-center gap-2"> 258 - <Show when={props.activeUri}> 269 + <div class="flex items-center gap-1"> 270 + <HistoryControls {...historyControls} /> 271 + </div> 272 + <div class="flex flex-1 items-center justify-end gap-2"> 273 + <Show when={local.activeUri}> 259 274 {(uri) => ( 260 275 <button 261 276 aria-label="Open full post" 262 277 class="inline-flex h-10 w-10 items-center justify-center rounded-xl border-0 bg-transparent text-on-surface-variant transition duration-150 ease-out hover:bg-surface-bright hover:text-on-surface" 263 278 type="button" 264 - onClick={() => props.onMaximize(uri())}> 279 + onClick={() => local.onMaximize(uri())}> 265 280 <Icon aria-hidden="true" iconClass="i-ri-external-link-line" /> 266 281 </button> 267 282 )} ··· 269 284 <button 270 285 class="inline-flex h-10 w-10 items-center justify-center rounded-xl border-0 bg-transparent text-on-surface-variant transition duration-150 ease-out hover:bg-surface-bright hover:text-on-surface" 271 286 type="button" 272 - onClick={() => props.onClose()}> 287 + onClick={() => local.onClose()}> 273 288 <Icon aria-hidden="true" iconClass="i-ri-close-line" /> 274 289 </button> 275 290 </div> ··· 318 333 <div class="grid gap-4"> 319 334 <Show when={threadNode().parent}> 320 335 {(parent) => ( 321 - <div class="tone-muted rounded-3xl p-3 shadow-[var(--inset-shadow)]"> 336 + <div class="tone-muted rounded-3xl p-3 shadow-(--inset-shadow)"> 322 337 <ThreadNodeView 323 338 activeUri={props.activeUri} 324 339 bookmarkPendingByUri={props.bookmarkPendingByUri} ··· 348 363 repostPending={!!props.repostPendingByUri[threadNode().post.uri]} /> 349 364 350 365 <Show when={threadNode().replies?.length}> 351 - <div class="tone-muted grid gap-4 rounded-3xl p-3 shadow-[var(--inset-shadow)]"> 366 + <div class="tone-muted grid gap-4 rounded-3xl p-3 shadow-(--inset-shadow)"> 352 367 <For each={threadNode().replies}> 353 368 {(reply) => ( 354 369 <ThreadNodeView ··· 376 391 377 392 function StateCard(props: { label: string; meta: string }) { 378 393 return ( 379 - <div class="tone-muted rounded-3xl p-4 shadow-[var(--inset-shadow)]"> 394 + <div class="tone-muted rounded-3xl p-4 shadow-(--inset-shadow)"> 380 395 <p class="m-0 text-sm font-semibold text-on-surface">{props.label}</p> 381 396 <p class="mt-1 text-xs text-on-surface-variant">{props.meta}</p> 382 397 </div> ··· 385 400 386 401 function SkeletonThreadCard() { 387 402 return ( 388 - <div class="tone-muted rounded-3xl p-5 shadow-[var(--inset-shadow)]"> 403 + <div class="tone-muted rounded-3xl p-5 shadow-(--inset-shadow)"> 389 404 <div class="flex gap-3"> 390 405 <div class="skeleton-block h-11 w-11 rounded-full" /> 391 406 <div class="min-w-0 flex-1">
+2 -2
src/components/profile/ProfileActorList.tsx
··· 99 99 const isFollowing = createMemo(() => !!props.actor.viewer?.following); 100 100 101 101 return ( 102 - <article class="tone-muted rounded-3xl p-4 shadow-[var(--inset-shadow)]"> 102 + <article class="tone-muted rounded-3xl p-4 shadow-(--inset-shadow)"> 103 103 <div class="flex items-start gap-3"> 104 104 <button 105 105 class="flex min-w-0 flex-1 items-start gap-3 border-0 bg-transparent p-0 text-left transition hover:opacity-90" ··· 189 189 <div class="grid gap-2 pt-1"> 190 190 <For each={Array.from({ length: 6 })}> 191 191 {() => ( 192 - <div class="tone-muted rounded-3xl p-4 shadow-[var(--inset-shadow)]"> 192 + <div class="tone-muted rounded-3xl p-4 shadow-(--inset-shadow)"> 193 193 <div class="flex items-start gap-3"> 194 194 <span class="skeleton-block h-11 w-11 shrink-0 rounded-full" /> 195 195 <div class="grid flex-1 gap-1.5">
+2 -2
src/components/profile/ProfileFeed.tsx
··· 62 62 <div class="grid gap-3"> 63 63 <For each={Array.from({ length: 3 })}> 64 64 {() => ( 65 - <div class="tone-muted rounded-3xl p-5 shadow-[var(--inset-shadow)]"> 65 + <div class="tone-muted rounded-3xl p-5 shadow-(--inset-shadow)"> 66 66 <div class="flex items-start gap-3"> 67 67 <span class="skeleton-block h-11 w-11 rounded-full" /> 68 68 <div class="grid min-w-0 flex-1 gap-2"> ··· 80 80 81 81 export function ProfileFeedMessage(props: { body: string; title: string }) { 82 82 return ( 83 - <div class="tone-muted grid place-items-center rounded-3xl px-6 py-12 text-center shadow-[var(--inset-shadow)]"> 83 + <div class="tone-muted grid place-items-center rounded-3xl px-6 py-12 text-center shadow-(--inset-shadow)"> 84 84 <div class="grid max-w-lg gap-2"> 85 85 <p class="m-0 text-lg font-semibold tracking-[-0.02em] text-on-surface">{props.title}</p> 86 86 <p class="m-0 text-sm leading-relaxed text-on-surface-variant">{props.body}</p>
+7 -7
src/components/profile/ProfileHero.tsx
··· 87 87 <div class="flex flex-wrap items-center justify-end gap-2"> 88 88 <For each={props.badges}> 89 89 {(badge) => ( 90 - <span class="tone-muted inline-flex items-center rounded-full px-3 py-2 text-xs font-medium text-on-surface shadow-[var(--inset-shadow)]"> 90 + <span class="tone-muted inline-flex items-center rounded-full px-3 py-2 text-xs font-medium text-on-surface shadow-(--inset-shadow)"> 91 91 {badge} 92 92 </span> 93 93 )} 94 94 </For> 95 95 <Show when={props.badges.length === 0}> 96 - <span class="tone-muted inline-flex items-center rounded-full px-3 py-2 text-xs font-medium text-on-surface-variant shadow-[var(--inset-shadow)]"> 96 + <span class="tone-muted inline-flex items-center rounded-full px-3 py-2 text-xs font-medium text-on-surface-variant shadow-(--inset-shadow)"> 97 97 {props.isSelf ? "Signed in" : "Public profile"} 98 98 </span> 99 99 </Show> ··· 136 136 <Show when={props.pinnedPostHref}> 137 137 {(href) => ( 138 138 <a 139 - class="tone-muted inline-flex items-center gap-2 rounded-full px-3 py-2 text-xs font-medium text-on-surface no-underline shadow-[var(--inset-shadow)] transition hover:-translate-y-px hover:bg-surface-bright" 139 + class="tone-muted inline-flex items-center gap-2 rounded-full px-3 py-2 text-xs font-medium text-on-surface no-underline shadow-(--inset-shadow) transition hover:-translate-y-px hover:bg-surface-bright" 140 140 href={`#${href()}`}> 141 141 <Icon iconClass="i-ri-pushpin-2-line" class="text-base" /> 142 142 <span>Pinned post</span> ··· 181 181 function MessageButton(props: { onClick: () => void }) { 182 182 return ( 183 183 <button 184 - class="tone-muted inline-flex min-h-9 items-center gap-2 rounded-full border ui-outline-subtle px-4 text-sm font-medium text-on-surface shadow-[var(--inset-shadow)] transition duration-150 ease-out hover:bg-surface-bright" 184 + class="tone-muted inline-flex min-h-9 items-center gap-2 rounded-full border ui-outline-subtle px-4 text-sm font-medium text-on-surface shadow-(--inset-shadow) transition duration-150 ease-out hover:bg-surface-bright" 185 185 type="button" 186 186 onClick={() => props.onClick()}> 187 187 <Icon kind="messages" class="text-base" /> ··· 231 231 </div> 232 232 233 233 <div class="relative z-10 -mt-16 px-6 pb-6 max-[760px]:px-4 max-[520px]:px-3"> 234 - <div class="grid gap-5 rounded-4xl bg-surface-container-highest px-5 pb-6 pt-5 shadow-[var(--inset-shadow)] backdrop-blur-[18px] max-[760px]:px-4 max-[520px]:px-3.5"> 234 + <div class="grid gap-5 rounded-4xl bg-surface-container-highest px-5 pb-6 pt-5 shadow-(--inset-shadow) backdrop-blur-[18px] max-[760px]:px-4 max-[520px]:px-3.5"> 235 235 <div class="flex flex-wrap items-start justify-between gap-5"> 236 236 <ProfileAvatar profile={props.profile} /> 237 237 ··· 296 296 <div 297 297 class="sticky top-0 z-30 px-3 pb-3 pt-3 backdrop-blur-[18px] max-[520px]:px-2" 298 298 data-testid="profile-sticky-header"> 299 - <div class="flex items-center gap-3 rounded-3xl bg-surface-container-high px-4 py-3 shadow-[var(--inset-shadow)]"> 299 + <div class="flex items-center gap-3 rounded-3xl bg-surface-container-high px-4 py-3 shadow-(--inset-shadow)"> 300 300 <ModeratedAvatar 301 301 avatar={props.profile.avatar} 302 302 class="relative h-12 w-12 shrink-0 overflow-hidden rounded-full bg-surface-container-high shadow-[0_0_0_2px_var(--surface),0_0_0_3px_rgba(125,175,255,0.22)]" ··· 317 317 <div class="ml-auto hidden flex-wrap justify-end gap-2 min-[720px]:flex"> 318 318 <For each={visibleBadges()}> 319 319 {(badge) => ( 320 - <span class="tone-muted inline-flex items-center rounded-full px-3 py-1.5 text-xs font-medium text-on-surface shadow-[var(--inset-shadow)]"> 320 + <span class="tone-muted inline-flex items-center rounded-full px-3 py-1.5 text-xs font-medium text-on-surface shadow-(--inset-shadow)"> 321 321 {badge} 322 322 </span> 323 323 )}
+4 -4
src/components/profile/ProfilePanel.tsx
··· 444 444 return ( 445 445 <section 446 446 class="relative grid min-h-0 overflow-hidden bg-surface-container" 447 - classList={{ "rounded-4xl shadow-[var(--inset-shadow)]": !props.embedded }}> 447 + classList={{ "rounded-4xl shadow-(--inset-shadow)": !props.embedded }}> 448 448 <div 449 449 data-testid="profile-scroll-region" 450 450 class="min-h-0 overflow-y-auto overscroll-contain" ··· 540 540 function ProfileLoadingView() { 541 541 return ( 542 542 <div class="grid gap-4 p-6 max-[760px]:p-4 max-[520px]:p-3"> 543 - <div class="tone-muted overflow-hidden rounded-4xl p-6 shadow-[var(--inset-shadow)]"> 543 + <div class="tone-muted overflow-hidden rounded-4xl p-6 shadow-(--inset-shadow)"> 544 544 <ProfileSkeleton /> 545 545 </div> 546 546 <ProfileFeedSkeleton /> ··· 553 553 554 554 return ( 555 555 <div class="grid min-h-120 place-items-center p-6"> 556 - <div class="tone-muted grid max-w-lg gap-4 rounded-4xl p-6 text-left shadow-[var(--inset-shadow)]"> 556 + <div class="tone-muted grid max-w-lg gap-4 rounded-4xl p-6 text-left shadow-(--inset-shadow)"> 557 557 <div class="flex items-center gap-3"> 558 558 <span class="ui-input-strong flex h-12 w-12 items-center justify-center rounded-full text-on-surface-variant"> 559 559 <Icon kind="danger" aria-hidden="true" /> ··· 594 594 <div 595 595 class="sticky z-20 px-3 pb-3 pt-1 backdrop-blur-[18px] max-[520px]:px-2" 596 596 classList={{ "top-22": props.compactHeaderVisible, "top-0": !props.compactHeaderVisible }}> 597 - <div class="rounded-3xl bg-surface-container-high p-2 shadow-[var(--inset-shadow)]"> 597 + <div class="rounded-3xl bg-surface-container-high p-2 shadow-(--inset-shadow)"> 598 598 <div class="flex flex-wrap gap-2"> 599 599 <For each={PROFILE_TABS}> 600 600 {(tab) => (
+7
src/components/rail/AppRail.test.tsx
··· 76 76 expect(await screen.findByRole("button", { name: "Theme menu" })).toBeInTheDocument(); 77 77 }); 78 78 79 + it("stretches the theme trigger to match full-width secondary rail actions", async () => { 80 + renderRail(); 81 + 82 + const trigger = await screen.findByRole("button", { name: "Theme menu" }); 83 + expect(trigger.className).toContain("w-full"); 84 + }); 85 + 79 86 it("hides the theme menu trigger when disabled in shell preferences", async () => { 80 87 renderRail({ shell: { showThemeRailControl: false } }); 81 88
+21 -111
src/components/rail/AppRail.tsx
··· 1 1 import { useAppPreferences } from "$/contexts/app-preferences"; 2 2 import { useAppSession } from "$/contexts/app-session"; 3 3 import { useAppShellUi } from "$/contexts/app-shell-ui"; 4 + import { useNavigationHistory } from "$/lib/navigation-history"; 4 5 import { normalizeThemeSetting } from "$/lib/theme"; 5 6 import type { Theme } from "$/lib/types"; 6 - import { useLocation, useNavigate } from "@solidjs/router"; 7 + import { useLocation } from "@solidjs/router"; 7 8 import { openUrl } from "@tauri-apps/plugin-opener"; 8 9 import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"; 9 10 import { AccountSwitcher } from "../account/AccountSwitcher"; 10 - import { ArrowIcon, Icon, RailFoldIcon } from "../shared/Icon"; 11 + import { HistoryControls } from "../shared/HistoryControls"; 12 + import { Icon, RailFoldIcon } from "../shared/Icon"; 11 13 import { Wordmark } from "../Wordmark"; 12 14 import { RailActionButton, RailButton } from "./AppRailButton"; 13 15 ··· 15 17 return ( 16 18 <> 17 19 <div 18 - class="flex shrink-0 items-center justify-between gap-3 max-[1180px]:min-w-0 max-[1180px]:justify-self-start" 20 + class="flex shrink-0 items-center justify-between gap-3 max-lg:min-w-0 max-lg:justify-self-start" 19 21 classList={{ "w-full flex-col gap-3": props.collapsed }}> 20 22 <Wordmark compact={props.collapsed} iconClass="text-primary" /> 21 23 22 - <div class="max-[1180px]:hidden"> 24 + <div class="max-lg:hidden"> 23 25 <button 24 26 class="ui-control ui-control-hoverable inline-flex h-10 w-10 items-center justify-center rounded-full" 25 27 type="button" ··· 34 36 ); 35 37 } 36 38 37 - function useShellHistoryTracker() { 38 - const location = useLocation(); 39 - const navigate = useNavigate(); 40 - const [entries, setEntries] = createSignal<string[]>([]); 41 - const [index, setIndex] = createSignal(-1); 42 - const routeKey = createMemo(() => `${location.pathname}${location.search}`); 43 - 44 - createEffect(() => { 45 - const key = routeKey(); 46 - const stack = entries(); 47 - const currentIndex = index(); 48 - 49 - if (stack.length === 0) { 50 - setEntries([key]); 51 - setIndex(0); 52 - return; 53 - } 54 - 55 - if (stack[currentIndex] === key) { 56 - return; 57 - } 58 - 59 - if (currentIndex > 0 && stack[currentIndex - 1] === key) { 60 - setIndex(currentIndex - 1); 61 - return; 62 - } 63 - 64 - if (currentIndex < stack.length - 1 && stack[currentIndex + 1] === key) { 65 - setIndex(currentIndex + 1); 66 - return; 67 - } 68 - 69 - const nextStack = [...stack.slice(0, currentIndex + 1), key]; 70 - setEntries(nextStack); 71 - setIndex(nextStack.length - 1); 72 - }); 73 - 74 - const canGoBack = createMemo(() => index() > 0); 75 - const canGoForward = createMemo(() => index() >= 0 && index() < entries().length - 1); 76 - 77 - function goBack() { 78 - if (!canGoBack()) { 79 - return; 80 - } 81 - 82 - void navigate(-1); 83 - } 84 - 85 - function goForward() { 86 - if (!canGoForward()) { 87 - return; 88 - } 89 - 90 - void navigate(1); 91 - } 92 - 93 - return { canGoBack, canGoForward, goBack, goForward }; 94 - } 95 - 96 39 function OverflowMenuButton(props: { hasSession: boolean }) { 97 40 const [open, setOpen] = createSignal(false); 98 41 const [menuPos, setMenuPos] = createSignal({ top: 0, left: 0 }); ··· 181 124 const useOverflowMenu = () => props.narrow || props.collapsed; 182 125 183 126 return ( 184 - <div class="grid gap-1 max-[1180px]:col-start-2 max-[1180px]:row-start-1 max-[1180px]:flex max-[1180px]:min-w-0 max-[1180px]:items-center max-[1180px]:gap-2 max-[1180px]:overflow-x-auto max-[1180px]:overscroll-contain max-[1180px]:[scrollbar-width:none] max-[1180px]:[&::-webkit-scrollbar]:hidden"> 127 + <div class="grid gap-1 max-lg:flex max-lg:min-w-0 max-lg:flex-1 max-lg:items-center max-lg:gap-2 max-lg:overflow-x-auto max-lg:overscroll-contain max-lg:[scrollbar-width:none] max-lg:[&::-webkit-scrollbar]:hidden"> 185 128 <Show 186 129 when={props.hasSession} 187 130 fallback={<RailButton end compact={props.collapsed} href="/auth" label="Accounts" icon="profile" />}> ··· 277 220 return ( 278 221 <div 279 222 ref={el => (containerRef = el)} 280 - class="flex relative max-[1180px]:col-start-4 max-[1180px]:row-start-1" 281 - classList={{ "justify-center": compact() }}> 223 + class="relative flex" 224 + classList={{ "w-full": !compact(), "justify-center": compact() }}> 282 225 <button 283 226 ref={el => (buttonRef = el)} 284 227 type="button" ··· 289 232 class="relative flex h-11 shrink-0 items-center gap-2.5 rounded-lg border-0 bg-transparent text-on-surface-variant no-underline transition duration-150 ease-out hover:-translate-y-px hover:bg-surface-bright hover:text-on-surface" 290 233 classList={{ 291 234 "w-[2.75rem] justify-center": compact(), 292 - "px-3": !compact(), 235 + "w-full justify-start px-3": !compact(), 293 236 "bg-surface-container text-primary": open(), 294 237 }}> 295 238 <Icon iconClass={iconClassForTheme(props.currentTheme)} class="shrink-0 text-[1.25rem]" /> ··· 329 272 330 273 function RailSecondaryActions(props: { collapsed: boolean }) { 331 274 return ( 332 - <div class="grid gap-1 max-[1180px]:hidden max-[1180px]:col-span-full max-[1180px]:grid-flow-col max-[1180px]:justify-start"> 275 + <div class="grid gap-1 max-lg:hidden max-lg:col-span-full max-lg:grid-flow-col max-lg:justify-start"> 333 276 <RailActionButton 334 277 compact={props.collapsed} 335 278 icon="heart" ··· 339 282 ); 340 283 } 341 284 342 - function RailHistoryControls( 343 - props: { 344 - canGoBack: boolean; 345 - canGoForward: boolean; 346 - collapsed: boolean; 347 - onGoBack: () => void; 348 - onGoForward: () => void; 349 - }, 350 - ) { 351 - return ( 352 - <div 353 - class="flex items-center gap-1 max-[1180px]:col-start-3 max-[1180px]:row-start-1" 354 - classList={{ "justify-self-center": props.collapsed }}> 355 - <button 356 - class="ui-control ui-control-hoverable inline-flex h-10 w-10 items-center justify-center rounded-full disabled:translate-y-0 disabled:cursor-not-allowed disabled:opacity-45" 357 - type="button" 358 - aria-label="Back" 359 - disabled={!props.canGoBack} 360 - onClick={() => props.onGoBack()}> 361 - <ArrowIcon direction="left" /> 362 - </button> 363 - 364 - <button 365 - class="ui-control ui-control-hoverable inline-flex h-10 w-10 items-center justify-center rounded-full disabled:translate-y-0 disabled:cursor-not-allowed disabled:opacity-45" 366 - type="button" 367 - aria-label="Forward" 368 - disabled={!props.canGoForward} 369 - onClick={() => props.onGoForward()}> 370 - <ArrowIcon direction="right" /> 371 - </button> 372 - </div> 373 - ); 374 - } 375 - 376 285 export function AppRail() { 377 286 const preferences = useAppPreferences(); 378 287 const session = useAppSession(); 379 288 const shell = useAppShellUi(); 380 - const history = useShellHistoryTracker(); 289 + const history = useNavigationHistory(); 381 290 const currentTheme = createMemo(() => normalizeThemeSetting(preferences.settings?.theme)); 382 291 383 292 async function handleChangeTheme(theme: Theme) { ··· 386 295 387 296 return ( 388 297 <aside 389 - class="flex min-h-screen min-w-0 flex-col gap-6 overflow-visible bg-surface-container-lowest px-6 pb-6 pt-6 transition-[padding,gap] duration-300 ease-out max-[1180px]:grid max-[1180px]:min-h-0 max-[1180px]:grid-cols-[auto_minmax(0,1fr)_auto_auto_auto] max-[1180px]:items-center max-[1180px]:gap-x-4 max-[1180px]:gap-y-3 max-[1180px]:p-4" 298 + class="flex min-h-screen min-w-0 flex-col gap-6 overflow-visible bg-surface-container-lowest px-6 pb-6 pt-6 transition-[padding,gap] duration-300 ease-out max-lg:grid max-lg:min-h-0 max-lg:grid-cols-[auto_minmax(0,1fr)_auto_auto_auto] max-lg:items-center max-lg:gap-x-4 max-lg:gap-y-3 max-lg:p-4" 390 299 classList={{ 391 300 "items-center px-4": shell.railCondensed && !shell.narrowViewport, 392 301 "gap-5": shell.railCondensed && !shell.narrowViewport, ··· 398 307 hasSession={session.hasSession} 399 308 narrow={shell.narrowViewport} 400 309 unreadNotifications={session.unreadNotifications} /> 401 - <div class="mt-auto grid gap-2 max-[1180px]:contents"> 310 + <div class="mt-auto grid gap-2 max-lg:contents"> 402 311 <Show when={!shell.railCondensed}> 403 312 <RailSecondaryActions collapsed={shell.railCondensed} /> 404 313 </Show> ··· 408 317 currentTheme={currentTheme()} 409 318 onChangeTheme={handleChangeTheme} /> 410 319 </Show> 411 - <RailHistoryControls 412 - canGoBack={history.canGoBack()} 413 - canGoForward={history.canGoForward()} 414 - collapsed={shell.railCondensed} 415 - onGoBack={history.goBack} 416 - onGoForward={history.goForward} /> 320 + <div class="flex items-center gap-1" classList={{ "w-full justify-center": shell.railCondensed }}> 321 + <HistoryControls 322 + canGoBack={history.canGoBack()} 323 + canGoForward={history.canGoForward()} 324 + onGoBack={history.goBack} 325 + onGoForward={history.goForward} /> 326 + </div> 417 327 <AccountSwitcher /> 418 328 </div> 419 329 </aside>
+2 -7
src/components/settings/SettingsPanel.tsx
··· 116 116 ) { 117 117 return ( 118 118 <div class="flex justify-end gap-2"> 119 - <button 120 - type="button" 121 - onClick={() => props.onCancel()} 122 - class="ui-button-secondary"> 123 - Cancel 124 - </button> 119 + <button type="button" onClick={() => props.onCancel()} class="ui-button-secondary">Cancel</button> 125 120 <button 126 121 type="button" 127 122 disabled={!props.confirmable} ··· 256 251 }); 257 252 258 253 return ( 259 - <article class="grid min-h-0 grid-rows-[auto_1fr] overflow-hidden rounded-4xl bg-surface-container shadow-[var(--inset-shadow)]"> 254 + <article class="grid min-h-0 grid-rows-[auto_1fr] overflow-hidden rounded-4xl bg-surface-container shadow-(--inset-shadow)"> 260 255 <header class="grid gap-5 px-6 pb-4 pt-6"> 261 256 <div class="flex items-center justify-between gap-4"> 262 257 <div class="grid gap-1">
+27
src/components/shared/HistoryControls.tsx
··· 1 + import { ArrowIcon } from "./Icon"; 2 + 3 + export function HistoryControls( 4 + props: { canGoBack: boolean; canGoForward: boolean; onGoBack: () => void; onGoForward: () => void }, 5 + ) { 6 + return ( 7 + <> 8 + <button 9 + class="ui-control ui-control-hoverable inline-flex h-8 w-8 items-center justify-center rounded-full disabled:translate-y-0 disabled:cursor-none disabled:opacity-45" 10 + type="button" 11 + aria-label="Back" 12 + disabled={!props.canGoBack} 13 + onClick={() => props.onGoBack()}> 14 + <ArrowIcon direction="left" /> 15 + </button> 16 + 17 + <button 18 + class="ui-control ui-control-hoverable inline-flex h-8 w-8 items-center justify-center rounded-full disabled:translate-y-0 disabled:cursor-none disabled:opacity-45" 19 + type="button" 20 + aria-label="Forward" 21 + disabled={!props.canGoForward} 22 + onClick={() => props.onGoForward()}> 23 + <ArrowIcon direction="right" /> 24 + </button> 25 + </> 26 + ); 27 + }
+2 -5
src/contexts/app-shell-ui.tsx
··· 66 66 } 67 67 68 68 onMount(() => { 69 - const media = globalThis.matchMedia("(max-width: 1180px)"); 69 + const media = globalThis.matchMedia("(max-width: 64rem)"); 70 70 const syncViewport = () => setShell("narrowViewport", media.matches); 71 71 72 72 const stored = globalThis.localStorage.getItem(RAIL_COLLAPSED_STORAGE_KEY); ··· 91 91 }); 92 92 93 93 createEffect(() => { 94 - globalThis.localStorage.setItem( 95 - RAIL_THEME_CONTROL_STORAGE_KEY, 96 - shell.showThemeRailControl ? "true" : "false", 97 - ); 94 + globalThis.localStorage.setItem(RAIL_THEME_CONTROL_STORAGE_KEY, shell.showThemeRailControl ? "true" : "false"); 98 95 }); 99 96 100 97 return {
+61
src/lib/navigation-history.ts
··· 1 + import { useLocation, useNavigate } from "@solidjs/router"; 2 + import { createEffect, createMemo, createSignal } from "solid-js"; 3 + 4 + export function useNavigationHistory() { 5 + const location = useLocation(); 6 + const navigate = useNavigate(); 7 + const [entries, setEntries] = createSignal<string[]>([]); 8 + const [index, setIndex] = createSignal(-1); 9 + const routeKey = createMemo(() => `${location.pathname}${location.search}`); 10 + 11 + createEffect(() => { 12 + const key = routeKey(); 13 + const stack = entries(); 14 + const currentIndex = index(); 15 + 16 + if (stack.length === 0) { 17 + setEntries([key]); 18 + setIndex(0); 19 + return; 20 + } 21 + 22 + if (stack[currentIndex] === key) { 23 + return; 24 + } 25 + 26 + if (currentIndex > 0 && stack[currentIndex - 1] === key) { 27 + setIndex(currentIndex - 1); 28 + return; 29 + } 30 + 31 + if (currentIndex < stack.length - 1 && stack[currentIndex + 1] === key) { 32 + setIndex(currentIndex + 1); 33 + return; 34 + } 35 + 36 + const nextStack = [...stack.slice(0, currentIndex + 1), key]; 37 + setEntries(nextStack); 38 + setIndex(nextStack.length - 1); 39 + }); 40 + 41 + const canGoBack = createMemo(() => index() > 0); 42 + const canGoForward = createMemo(() => index() >= 0 && index() < entries().length - 1); 43 + 44 + function goBack() { 45 + if (!canGoBack()) { 46 + return; 47 + } 48 + 49 + void navigate(-1); 50 + } 51 + 52 + function goForward() { 53 + if (!canGoForward()) { 54 + return; 55 + } 56 + 57 + void navigate(1); 58 + } 59 + 60 + return { canGoBack, canGoForward, goBack, goForward }; 61 + }