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.

feat: make UI responsive and add hash routing

+462 -152
+3 -3
docs/design.md
··· 8 8 9 9 ## Color and Surfaces 10 10 11 - Space is defined by light and depth, not lines. 11 + Space is defined by light and depth, and subtle lines. No gradients. 12 12 13 13 - **No-Line Rule:** Do not use 1px solid borders for sectioning. Separate regions by stepping between surface tokens (for example, `surface` to `surface_container_low`). 14 14 - **Surface hierarchy:** ··· 16 16 - **Primary work surface:** `surface` (`#0e0e0e`) for the default content canvas. 17 17 - **Elevated containers:** `surface_container` (`#191919`) and `surface_container_high` (`#1f1f1f`) for cards and interactive panels. 18 18 - **Glass overlays:** Floating modals and high-priority overlays use `surface_container_highest` at 70% opacity with `backdrop-blur: 20px` for an obsidian-glass effect. 19 - - **Primary CTA texture:** Use a linear gradient (135deg) from `primary` (`#7dafff`) to `primary_dim` (`#0073de`), not a flat fill. 19 + - **Primary CTA texture:** Use a properly contrasted background 20 20 21 21 ## Typography 22 22 ··· 45 45 46 46 ### Buttons 47 47 48 - - **Primary:** Gradient fill (`primary` to `primary_dim`), text color `on_primary_fixed` (black), radius `full`. 48 + - **Primary:** Solid fill (`primary` to `primary_dim`), text color `on_primary_fixed` (black), small radius (`sm`, 0.375rem). 49 49 - **Secondary:** Fill `surface_container_highest`, text `on_surface`, no border, radius `md` (0.75rem). 50 50 - **Tertiary:** Transparent background, text `primary`; use for low-emphasis actions (for example, Cancel). 51 51
+1 -1
public/lazurite.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" width="2em" height="2em" viewBox="0 0 512 512"> 1 + <svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"> 2 2 <path fill="currentColor" d="M128 16v99.3l119 118.9V120.1zm256 0L265 120.1v114.1l119-119zM16 128l104 119h114.2L115.3 128zm380.8 0l-119 119h114.1l104-119zM120 265L16 384h99.2l119-119zm157.8 0l119 119h99.1l-104-119zM247 277.8l-119 119V496l119-104.1zm18 0v114.1L384 496v-99.2z"/> 3 3 </svg>
public/tray-icon.png

This is a binary file and will not be displayed.

+207 -93
src/App.tsx
··· 1 1 import { invoke } from "@tauri-apps/api/core"; 2 2 import { listen } from "@tauri-apps/api/event"; 3 - import { createMemo, createSignal, onCleanup, onMount, Show, startTransition } from "solid-js"; 3 + import { createEffect, createMemo, type JSX, onCleanup, onMount, Show, startTransition } from "solid-js"; 4 + import { createStore } from "solid-js/store"; 4 5 import "@fontsource-variable/google-sans"; 5 6 import "./App.css"; 6 7 import { AccountLedger } from "./components/AccountLedger"; ··· 11 12 import { RailButton } from "./components/RailButton"; 12 13 import { SessionSpotlight } from "./components/Session"; 13 14 import { ErrorToast } from "./components/shared/ErrorToast"; 15 + import { ArrowIcon } from "./components/shared/Icon"; 14 16 import { Wordmark } from "./components/Wordmark"; 15 17 import type { AccountSummary, ActiveSession, AppBootstrap } from "./lib/types"; 18 + import { AppRouter } from "./router"; 16 19 17 20 const ACCOUNT_SWITCH_EVENT = "auth:account-switched"; 21 + const RAIL_COLLAPSED_STORAGE_KEY = "lazurite:rail-collapsed"; 22 + 23 + type AppState = { 24 + accounts: AccountSummary[]; 25 + activeSession: ActiveSession | null; 26 + bootstrapping: boolean; 27 + errorMessage: string | null; 28 + loggingIn: boolean; 29 + loginValue: string; 30 + logoutDid: string | null; 31 + narrowViewport: boolean; 32 + railCollapsed: boolean; 33 + reauthNeeded: boolean; 34 + shakeCount: number; 35 + showSwitcher: boolean; 36 + switchingDid: string | null; 37 + }; 38 + 39 + function createInitialAppState(): AppState { 40 + return { 41 + accounts: [], 42 + activeSession: null, 43 + bootstrapping: true, 44 + errorMessage: null, 45 + loggingIn: false, 46 + loginValue: "", 47 + logoutDid: null, 48 + narrowViewport: false, 49 + railCollapsed: false, 50 + reauthNeeded: false, 51 + shakeCount: 0, 52 + showSwitcher: false, 53 + switchingDid: null, 54 + }; 55 + } 18 56 19 57 function App() { 20 - const [bootstrapping, setBootstrapping] = createSignal(true); 21 - const [activeSession, setActiveSession] = createSignal<ActiveSession | null>(null); 22 - const [accounts, setAccounts] = createSignal<AccountSummary[]>([]); 23 - const [loginValue, setLoginValue] = createSignal(""); 24 - const [loggingIn, setLoggingIn] = createSignal(false); 25 - const [switchingDid, setSwitchingDid] = createSignal<string | null>(null); 26 - const [logoutDid, setLogoutDid] = createSignal<string | null>(null); 27 - const [showSwitcher, setShowSwitcher] = createSignal(false); 28 - const [errorMessage, setErrorMessage] = createSignal<string | null>(null); 29 - const [shakeCount, setShakeCount] = createSignal(0); 30 - const [reauthNeeded, setReauthNeeded] = createSignal(false); 58 + const [app, setApp] = createStore<AppState>(createInitialAppState()); 31 59 32 - const activeAccount = createMemo(() => accounts().find((account) => account.did === activeSession()?.did) ?? null); 33 - const primaryAccount = createMemo(() => activeAccount() ?? accounts()[0] ?? null); 34 - const hasSession = createMemo(() => !!activeSession()); 60 + const activeAccount = createMemo(() => 61 + app.accounts.find((account) => account.did === app.activeSession?.did) ?? null 62 + ); 63 + const primaryAccount = createMemo(() => activeAccount() ?? app.accounts[0] ?? null); 64 + const hasSession = createMemo(() => !!app.activeSession); 65 + const railCompact = createMemo(() => app.railCollapsed && !app.narrowViewport); 66 + const railColumns = createMemo(() => (railCompact() ? "5.75rem minmax(0,1fr)" : "16rem minmax(0,1fr)")); 35 67 const metaLabel = createMemo(() => { 36 - if (bootstrapping()) { 68 + if (app.bootstrapping) { 37 69 return "signing you back in"; 38 70 } 39 71 40 - if (activeSession()) { 72 + if (app.activeSession) { 41 73 return "signed in"; 42 74 } 43 75 ··· 45 77 }); 46 78 47 79 async function loadBootstrap() { 48 - setBootstrapping(true); 80 + setApp("bootstrapping", true); 49 81 50 82 try { 51 83 const payload = await invoke<AppBootstrap>("get_app_bootstrap"); 52 84 startTransition(() => { 53 - setActiveSession(payload.activeSession); 54 - setAccounts(payload.accountList); 55 - setReauthNeeded(payload.accountList.length > 0 && !payload.activeSession); 85 + setApp("activeSession", payload.activeSession); 86 + setApp("accounts", payload.accountList); 87 + setApp("reauthNeeded", payload.accountList.length > 0 && !payload.activeSession); 56 88 }); 57 89 } catch (error) { 58 - setErrorMessage(`Failed to load app bootstrap: ${String(error)}`); 90 + setApp("errorMessage", `Failed to load app bootstrap: ${String(error)}`); 59 91 } finally { 60 - setBootstrapping(false); 92 + setApp("bootstrapping", false); 61 93 } 62 94 } 63 95 64 96 function closeSwitcher() { 65 - if (showSwitcher()) { 66 - setShowSwitcher(false); 97 + if (app.showSwitcher) { 98 + setApp("showSwitcher", false); 67 99 } 68 100 } 69 101 70 102 function triggerShake() { 71 - setShakeCount((count) => count + 1); 103 + setApp("shakeCount", (count) => count + 1); 72 104 } 73 105 74 106 function markPotentialExpiry(error: unknown) { 75 107 const message = String(error).toLowerCase(); 76 108 if (message.includes("refresh failed") || message.includes("session does not exist")) { 77 - setReauthNeeded(true); 109 + setApp("reauthNeeded", true); 78 110 } 79 111 } 80 112 81 - async function submitLogin(identifier = loginValue()) { 113 + async function submitLogin(identifier = app.loginValue) { 82 114 const trimmed = identifier.trim(); 83 115 if (!validateIdentifier(trimmed)) { 84 116 triggerShake(); 85 - setErrorMessage("Enter a valid Bluesky handle, DID, or PDS URL."); 117 + setApp("errorMessage", "Enter a valid Bluesky handle, DID, or PDS URL."); 86 118 return; 87 119 } 88 120 89 - setLoggingIn(true); 121 + setApp("loggingIn", true); 90 122 try { 91 123 await invoke("login", { handle: trimmed }); 92 - setLoginValue(""); 124 + setApp("loginValue", ""); 93 125 closeSwitcher(); 94 126 await loadBootstrap(); 95 127 } catch (error) { 96 128 markPotentialExpiry(error); 97 - setErrorMessage(`Authentication failed: ${String(error)}`); 129 + setApp("errorMessage", `Authentication failed: ${String(error)}`); 98 130 } finally { 99 - setLoggingIn(false); 131 + setApp("loggingIn", false); 100 132 } 101 133 } 102 134 103 135 async function switchAccount(did: string) { 104 - setSwitchingDid(did); 136 + setApp("switchingDid", did); 105 137 try { 106 138 await invoke("switch_account", { did }); 107 139 closeSwitcher(); 108 140 await loadBootstrap(); 109 141 } catch (error) { 110 142 markPotentialExpiry(error); 111 - setErrorMessage(`Failed to switch account: ${String(error)}`); 143 + setApp("errorMessage", `Failed to switch account: ${String(error)}`); 112 144 } finally { 113 - setSwitchingDid(null); 145 + setApp("switchingDid", null); 114 146 } 115 147 } 116 148 117 149 async function logout(did: string) { 118 - setLogoutDid(did); 150 + setApp("logoutDid", did); 119 151 try { 120 152 await invoke("logout", { did }); 121 153 closeSwitcher(); 122 154 await loadBootstrap(); 123 155 } catch (error) { 124 156 markPotentialExpiry(error); 125 - setErrorMessage(`Failed to logout account: ${String(error)}`); 157 + setApp("errorMessage", `Failed to logout account: ${String(error)}`); 126 158 } finally { 127 - setLogoutDid(null); 159 + setApp("logoutDid", null); 128 160 } 129 161 } 130 162 ··· 139 171 140 172 onMount(() => { 141 173 let unlisten: (() => void) | undefined; 174 + const media = globalThis.matchMedia("(max-width: 1180px)"); 175 + const syncViewport = () => setApp("narrowViewport", media.matches); 176 + 177 + const stored = globalThis.localStorage.getItem(RAIL_COLLAPSED_STORAGE_KEY); 178 + if (stored === "true") { 179 + setApp("railCollapsed", true); 180 + } 181 + 182 + syncViewport(); 183 + media.addEventListener("change", syncViewport); 142 184 143 185 void loadBootstrap(); 144 186 ··· 148 190 unlisten = dispose; 149 191 }); 150 192 151 - onCleanup(() => unlisten?.()); 193 + onCleanup(() => { 194 + unlisten?.(); 195 + media.removeEventListener("change", syncViewport); 196 + }); 197 + }); 198 + 199 + createEffect(() => { 200 + globalThis.localStorage.setItem(RAIL_COLLAPSED_STORAGE_KEY, app.railCollapsed ? "true" : "false"); 152 201 }); 153 202 154 - return ( 155 - <> 156 - <main class="grid min-h-screen grid-cols-[16rem_minmax(0,1fr)] max-[1180px]:grid-cols-1"> 157 - <aside 158 - class="flex min-h-screen flex-col gap-8 bg-surface-container-lowest px-6 pb-6 pt-8 max-[1180px]:min-h-0 max-[1180px]:grid max-[1180px]:grid-cols-[auto_auto_minmax(18rem,1fr)] max-[1180px]:items-center max-[1180px]:gap-4 max-[1180px]:p-4 max-[760px]:grid-cols-1" 159 - aria-label="Primary navigation"> 160 - <Wordmark /> 161 - <RailNavigation hasSession={hasSession()} /> 162 - <AccountSwitcher 163 - activeSession={activeSession()} 164 - accounts={accounts()} 165 - busyDid={switchingDid()} 166 - logoutDid={logoutDid()} 167 - open={showSwitcher()} 168 - onToggle={() => setShowSwitcher((open) => !open)} 203 + function AppShell(props: { children: JSX.Element }) { 204 + return ( 205 + <> 206 + <main 207 + class="grid min-h-screen grid-cols-[var(--app-rail-cols)] transition-[grid-template-columns] duration-300 ease-out max-[1180px]:grid-cols-1" 208 + style={{ "--app-rail-cols": railColumns() }}> 209 + <AppRail 210 + activeSession={app.activeSession} 211 + accounts={app.accounts} 212 + collapsed={railCompact()} 213 + hasSession={hasSession()} 214 + logoutDid={app.logoutDid} 215 + openSwitcher={app.showSwitcher} 216 + switchingDid={app.switchingDid} 217 + onLogout={(did) => void logout(did)} 169 218 onSwitch={(did) => void switchAccount(did)} 170 - onLogout={(did) => void logout(did)} /> 171 - </aside> 219 + onToggleCollapse={() => setApp("railCollapsed", (collapsed) => !collapsed)} 220 + onToggleSwitcher={() => setApp("showSwitcher", (open) => !open)} /> 172 221 173 - <section 174 - class="m-5 grid gap-8 rounded-4xl bg-[linear-gradient(160deg,rgba(14,14,14,0.92),rgba(25,25,25,0.98))] p-8 shadow-[0_24px_40px_rgba(125,175,255,0.05)] max-[1360px]:p-7 max-[1180px]:m-0 max-[1180px]:min-h-[calc(100vh-5.5rem)] max-[1180px]:rounded-none max-[1180px]:p-6 max-[760px]:gap-6 max-[760px]:p-5" 175 - aria-busy={bootstrapping()}> 176 - <Show 177 - when={activeSession()} 178 - keyed 179 - fallback={ 180 - <AuthWorkspace 181 - accounts={accounts()} 182 - activeAccount={activeAccount()} 183 - activeDid={activeSession()?.did ?? null} 184 - bootstrapping={bootstrapping()} 185 - loggingIn={loggingIn()} 186 - loginValue={loginValue()} 187 - logoutDid={logoutDid()} 188 - metaLabel={metaLabel()} 189 - reauthNeeded={reauthNeeded()} 190 - shakeCount={shakeCount()} 191 - switchingDid={switchingDid()} 192 - onInput={setLoginValue} 193 - onLogout={(did) => void logout(did)} 194 - onReauth={() => void reauthorizePrimaryAccount()} 195 - onSubmit={() => void submitLogin()} 196 - onSwitch={(did) => void switchAccount(did)} /> 197 - }> 198 - {(session) => <FeedWorkspace activeSession={session} onError={setErrorMessage} />} 199 - </Show> 200 - </section> 201 - </main> 222 + <section 223 + class="m-5 grid gap-8 rounded-4xl bg-[linear-gradient(160deg,rgba(14,14,14,0.92),rgba(25,25,25,0.98))] p-8 shadow-[0_24px_40px_rgba(125,175,255,0.05)] max-[1360px]:p-7 max-[1180px]:m-0 max-[1180px]:min-h-[calc(100vh-5.5rem)] max-[1180px]:rounded-none max-[1180px]:p-6 max-[760px]:gap-6 max-[760px]:p-5" 224 + aria-busy={app.bootstrapping}> 225 + {props.children} 226 + </section> 227 + </main> 228 + 229 + <ErrorToast message={app.errorMessage} onDismiss={() => setApp("errorMessage", null)} /> 230 + </> 231 + ); 232 + } 202 233 203 - <ErrorToast message={errorMessage} onDismiss={() => setErrorMessage(null)} /> 204 - </> 234 + return ( 235 + <AppRouter 236 + bootstrapping={app.bootstrapping} 237 + hasSession={hasSession()} 238 + session={app.activeSession} 239 + onLocationChange={() => setApp("showSwitcher", false)} 240 + renderAuth={() => ( 241 + <AuthWorkspace 242 + accounts={app.accounts} 243 + activeAccount={activeAccount()} 244 + activeDid={app.activeSession?.did ?? null} 245 + bootstrapping={app.bootstrapping} 246 + loggingIn={app.loggingIn} 247 + loginValue={app.loginValue} 248 + logoutDid={app.logoutDid} 249 + metaLabel={metaLabel()} 250 + reauthNeeded={app.reauthNeeded} 251 + shakeCount={app.shakeCount} 252 + switchingDid={app.switchingDid} 253 + onInput={(value) => setApp("loginValue", value)} 254 + onLogout={(did) => void logout(did)} 255 + onReauth={() => void reauthorizePrimaryAccount()} 256 + onSubmit={() => void submitLogin()} 257 + onSwitch={(did) => void switchAccount(did)} /> 258 + )} 259 + renderShell={AppShell} 260 + renderTimeline={(session) => ( 261 + <FeedWorkspace activeSession={session} onError={(message) => setApp("errorMessage", message)} /> 262 + )} /> 263 + ); 264 + } 265 + 266 + function AppRail( 267 + props: { 268 + activeSession: ActiveSession | null; 269 + accounts: AccountSummary[]; 270 + collapsed: boolean; 271 + hasSession: boolean; 272 + logoutDid: string | null; 273 + openSwitcher: boolean; 274 + switchingDid: string | null; 275 + onLogout: (did: string) => void; 276 + onSwitch: (did: string) => void; 277 + onToggleCollapse: () => void; 278 + onToggleSwitcher: () => void; 279 + }, 280 + ) { 281 + return ( 282 + <aside 283 + class="flex min-h-screen flex-col gap-8 overflow-visible bg-surface-container-lowest px-6 pb-6 pt-8 transition-[padding,gap] duration-300 ease-out max-[1180px]:min-h-0 max-[1180px]:grid max-[1180px]:grid-cols-[auto_auto_minmax(18rem,1fr)] max-[1180px]:items-center max-[1180px]:gap-4 max-[1180px]:p-4 max-[760px]:grid-cols-1" 284 + classList={{ "items-center px-4": props.collapsed, "gap-6": props.collapsed }} 285 + aria-label="Primary navigation"> 286 + <RailHeader collapsed={props.collapsed} onToggleCollapse={props.onToggleCollapse} /> 287 + <RailNavigation hasSession={props.hasSession} /> 288 + <AccountSwitcher 289 + activeSession={props.activeSession} 290 + accounts={props.accounts} 291 + busyDid={props.switchingDid} 292 + compact={props.collapsed} 293 + logoutDid={props.logoutDid} 294 + open={props.openSwitcher} 295 + onToggle={props.onToggleSwitcher} 296 + onSwitch={props.onSwitch} 297 + onLogout={props.onLogout} /> 298 + </aside> 299 + ); 300 + } 301 + 302 + function RailHeader(props: { collapsed: boolean; onToggleCollapse: () => void }) { 303 + return ( 304 + <div 305 + class="flex items-start justify-between gap-3 max-[1180px]:items-center" 306 + classList={{ "w-full flex-col items-center gap-4": props.collapsed }}> 307 + <Wordmark compact={props.collapsed} iconClass="text-primary" /> 308 + <button 309 + class="inline-flex h-10 w-10 items-center justify-center rounded-full border-0 bg-white/[0.04] text-on-surface-variant shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)] transition duration-150 ease-out hover:-translate-y-px hover:bg-white/[0.08] hover:text-on-surface max-[1180px]:hidden" 310 + type="button" 311 + aria-label={props.collapsed ? "Expand app rail" : "Collapse app rail"} 312 + aria-pressed={props.collapsed} 313 + onClick={() => props.onToggleCollapse()}> 314 + <Show when={props.collapsed} fallback={<ArrowIcon direction="left" />}> 315 + <ArrowIcon direction="right" /> 316 + </Show> 317 + </button> 318 + </div> 205 319 ); 206 320 } 207 321 208 322 function RailNavigation(props: { hasSession: boolean }) { 209 323 return ( 210 324 <div class="grid gap-2 max-[1180px]:flex max-[1180px]:items-center"> 211 - <Show when={props.hasSession} fallback={<RailButton label="Accounts" icon="profile" active />}> 325 + <Show when={props.hasSession} fallback={<RailButton end href="/auth" label="Accounts" icon="profile" />}> 212 326 <> 213 - <RailButton label="Timeline" icon="timeline" active /> 214 - <RailButton label="Search" icon="search" /> 215 - <RailButton label="Notifications" icon="notifications" /> 216 - <RailButton label="Explorer" icon="explorer" /> 327 + <RailButton end href="/timeline" label="Timeline" icon="timeline" /> 328 + <RailButton end href="/search" label="Search" icon="search" /> 329 + <RailButton end href="/notifications" label="Notifications" icon="notifications" /> 330 + <RailButton end href="/explorer" label="Explorer" icon="explorer" /> 217 331 </> 218 332 </Show> 219 333 </div>
+35 -7
src/components/AccountSwitcher.tsx
··· 9 9 activeSession: ActiveSession | null; 10 10 accounts: AccountSummary[]; 11 11 busyDid: string | null; 12 + compact?: boolean; 12 13 logoutDid: string | null; 13 14 open: boolean; 14 15 onToggle: () => void; ··· 23 24 onMount(() => { 24 25 const pointerListener = { 25 26 handleEvent(event: Event) { 26 - if (!isOpen) { 27 + if (!isOpen()) { 27 28 return; 28 29 } 29 30 ··· 41 42 42 43 return ( 43 44 <div 44 - class="relative mt-auto w-full max-[1180px]:mt-0 max-[1180px]:max-w-[24rem] max-[1180px]:justify-self-end max-[760px]:max-w-none" 45 + class="relative mt-auto w-full transition-[width,max-width] duration-300 ease-out max-[1180px]:mt-0 max-[1180px]:max-w-[24rem] max-[1180px]:justify-self-end max-[760px]:max-w-none" 46 + classList={{ "w-auto": !!props.compact }} 45 47 ref={(element) => { 46 48 container = element; 47 49 }}> 48 50 <button 49 - class="relative w-full cursor-pointer rounded-3xl border-0 bg-[linear-gradient(160deg,rgba(255,255,255,0.045),rgba(255,255,255,0.02))] px-4 py-[0.95rem] text-on-surface shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)] transition duration-150 ease-out hover:-translate-y-px hover:bg-[linear-gradient(160deg,rgba(255,255,255,0.08),rgba(255,255,255,0.03))]" 51 + class="relative w-full cursor-pointer border-0 bg-[linear-gradient(160deg,rgba(255,255,255,0.045),rgba(255,255,255,0.02))] text-on-surface shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)] transition duration-150 ease-out hover:-translate-y-px hover:bg-[linear-gradient(160deg,rgba(255,255,255,0.08),rgba(255,255,255,0.03))]" 52 + classList={{ 53 + "rounded-3xl px-4 py-[0.95rem]": !props.compact, 54 + "grid h-14 w-14 place-items-center rounded-full p-0": !!props.compact, 55 + }} 50 56 type="button" 51 57 aria-haspopup="menu" 52 58 aria-expanded={props.open} 59 + aria-label={props.activeSession ? `Current account ${props.activeSession.handle}` : "Add account"} 53 60 onClick={() => props.onToggle()}> 54 61 <Presence exitBeforeEnter> 55 62 <Show 56 63 when={props.activeSession} 57 64 keyed 58 - fallback={<SwitcherIdentity label="?" name="Add account" meta="No active account" tone="muted" />}> 65 + fallback={ 66 + <SwitcherIdentity 67 + compact={props.compact} 68 + label="?" 69 + name="Add account" 70 + meta="No active account" 71 + tone="muted" /> 72 + }> 59 73 {(session) => ( 60 - <SwitcherIdentity label={session.handle} name={session.handle} meta="Current account" tone="primary" /> 74 + <SwitcherIdentity 75 + compact={props.compact} 76 + label={session.handle} 77 + name={session.handle} 78 + meta="Current account" 79 + tone="primary" /> 61 80 )} 62 81 </Show> 63 82 </Presence> 64 83 <span 65 - class="absolute right-[0.95rem] top-[1.15rem] flex items-center text-on-surface-variant" 84 + class="absolute flex items-center text-on-surface-variant" 85 + classList={{ 86 + "right-[0.95rem] top-[1.15rem]": !props.compact, 87 + "bottom-0 right-0 h-5 w-5 rounded-full bg-surface-container text-[0.7rem] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]": 88 + !!props.compact, 89 + }} 66 90 aria-hidden="true"> 67 91 <Show when={props.open} fallback={<ArrowIcon direction="down" />}> 68 92 <ArrowIcon direction="up" /> ··· 73 97 <Presence> 74 98 <Show when={props.open}> 75 99 <Motion.div 76 - class="absolute inset-x-0 bottom-[calc(100%+0.75rem)] rounded-3xl bg-(--surface-container-highest) p-4 shadow-[0_24px_40px_rgba(0,0,0,0.28)] backdrop-blur-[20px] max-[1180px]:bottom-auto max-[1180px]:top-[calc(100%+0.75rem)]" 100 + class="absolute rounded-3xl bg-(--surface-container-highest) p-4 shadow-[0_24px_40px_rgba(0,0,0,0.28)] backdrop-blur-[20px] max-[1180px]:bottom-auto max-[1180px]:top-[calc(100%+0.75rem)]" 101 + classList={{ 102 + "inset-x-0 bottom-[calc(100%+0.75rem)]": !props.compact, 103 + "bottom-0 left-[calc(100%+0.85rem)] w-[19rem]": !!props.compact, 104 + }} 77 105 role="menu" 78 106 initial={{ opacity: 0, y: 10, scale: 0.98 }} 79 107 animate={{ opacity: 1, y: 0, scale: 1 }}
+11 -5
src/components/AccountSwitcherIdentity.tsx
··· 1 + import { Show } from "solid-js"; 1 2 import { Motion } from "solid-motionone"; 2 3 import { AvatarBadge } from "./AvatarBadge"; 3 4 4 - export function SwitcherIdentity(props: { label: string; name: string; meta: string; tone: "primary" | "muted" }) { 5 + export function SwitcherIdentity( 6 + props: { compact?: boolean; label: string; name: string; meta: string; tone: "primary" | "muted" }, 7 + ) { 5 8 return ( 6 9 <Motion.div 7 10 class="flex items-center gap-3" 11 + classList={{ "justify-center": !!props.compact }} 8 12 initial={{ opacity: 0, y: 8, scale: 0.96 }} 9 13 animate={{ opacity: 1, y: 0, scale: 1 }} 10 14 exit={{ opacity: 0, y: -6, scale: 0.94 }} 11 15 transition={{ duration: 0.24 }}> 12 16 <AvatarBadge label={props.label} tone={props.tone} /> 13 - <div class="grid"> 14 - <span class="text-[0.92rem] font-semibold">{props.name}</span> 15 - <span class="text-xs text-on-surface-variant">{props.meta}</span> 16 - </div> 17 + <Show when={!props.compact}> 18 + <div class="grid"> 19 + <span class="text-[0.92rem] font-semibold">{props.name}</span> 20 + <span class="text-xs text-on-surface-variant">{props.meta}</span> 21 + </div> 22 + </Show> 17 23 </Motion.div> 18 24 ); 19 25 }
+10 -6
src/components/RailButton.tsx
··· 1 + import { A } from "@solidjs/router"; 1 2 import { Icon, type IconKind } from "./shared/Icon"; 2 3 3 - type RailButtonProps = { label: string; icon: IconKind; active?: boolean }; 4 + type RailButtonProps = { label: string; href: string; icon: IconKind; end?: boolean }; 4 5 5 6 export function RailButton(props: RailButtonProps) { 6 7 return ( 7 - <button 8 + <A 9 + href={props.href} 10 + end={props.end} 8 11 class="grid h-[3.3rem] place-items-center rounded-full border-0 bg-transparent text-on-surface-variant transition duration-150 ease-out hover:-translate-y-px hover:bg-surface-bright hover:text-on-surface" 9 - classList={{ "bg-surface-container text-primary": !!props.active }} 10 - type="button" 11 - aria-label={props.label}> 12 + activeClass="bg-surface-container text-primary" 13 + inactiveClass="" 14 + aria-label={props.label} 15 + title={props.label}> 12 16 <Icon kind={props.icon} name={props.label} aria-hidden="true" /> 13 - </button> 17 + </A> 14 18 ); 15 19 }
+17 -8
src/components/Wordmark.tsx
··· 1 - export function Wordmark() { 1 + import { Show } from "solid-js"; 2 + 3 + export function Wordmark(props: { class?: string; compact?: boolean; iconClass?: string }) { 2 4 return ( 3 - <div class="flex items-center gap-3"> 5 + <div 6 + class="flex items-center gap-3" 7 + classList={{ "flex-col gap-2 text-center": !!props.compact, [props.class ?? ""]: !!props.class }}> 4 8 <span 5 - class="h-[2.7rem] w-[0.95rem] rounded-full bg-[linear-gradient(180deg,var(--primary)_0%,var(--primary-dim)_100%)] shadow-[0_0_24px_rgba(125,175,255,0.24)]" 6 - aria-hidden="true" /> 7 - <div class="grid"> 8 - <p class="m-0 text-[0.9rem]">Lazurite</p> 9 - <p class="overline-copy text-[0.68rem] text-on-surface-variant">Desktop</p> 10 - </div> 9 + class="grid shrink-0 place-items-center rounded-3xl bg-[linear-gradient(165deg,rgba(255,255,255,0.06),rgba(255,255,255,0.01))] p-3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04),0_0_26px_rgba(125,175,255,0.16)]" 10 + classList={{ [props.iconClass ?? ""]: !!props.iconClass }} 11 + aria-hidden="true"> 12 + <img class="h-9 w-9 drop-shadow-[0_0_14px_rgba(125,175,255,0.28)]" src="/lazurite.svg" alt="" /> 13 + </span> 14 + <Show when={!props.compact}> 15 + <div class="grid"> 16 + <p class="m-0 text-[0.9rem] font-semibold tracking-tight">Lazurite</p> 17 + <p class="overline-copy text-[0.68rem] text-on-surface-variant">Desktop</p> 18 + </div> 19 + </Show> 11 20 </div> 12 21 ); 13 22 }
+1 -1
src/components/feeds/FeedWorkspace.tsx
··· 967 967 </Show> 968 968 <Show when={props.activeFeedState?.error}> 969 969 {(message) => ( 970 - <div class="rounded-[1.4rem] bg-[rgba(138,31,31,0.2)] p-4 text-sm text-error shadow-[inset_0_0_0_1px_rgba(255,128,128,0.2)]"> 970 + <div class="rounded-3xl bg-[rgba(138,31,31,0.2)] p-4 text-sm text-error shadow-[inset_0_0_0_1px_rgba(255,128,128,0.2)]"> 971 971 {message()} 972 972 </div> 973 973 )}
+3 -3
src/components/feeds/ThreadPanel.tsx
··· 35 35 36 36 <Show when={!props.loading && props.error}> 37 37 {(message) => ( 38 - <div class="rounded-[1.4rem] bg-[rgba(138,31,31,0.2)] p-4 text-sm text-error shadow-[inset_0_0_0_1px_rgba(255,128,128,0.2)]"> 38 + <div class="rounded-3xl bg-[rgba(138,31,31,0.2)] p-4 text-sm text-error shadow-[inset_0_0_0_1px_rgba(255,128,128,0.2)]"> 39 39 {message()} 40 40 </div> 41 41 )} ··· 64 64 65 65 function ThreadPanelHeader(props: { onClose: () => void }) { 66 66 return ( 67 - <header class="sticky top-0 z-10 mb-4 flex items-center justify-between rounded-[1.4rem] bg-[rgba(14,14,14,0.9)] px-4 py-3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 67 + <header class="sticky top-0 z-10 mb-4 flex items-center justify-between rounded-3xl bg-[rgba(14,14,14,0.9)] px-4 py-3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 68 68 <div> 69 69 <p class="m-0 text-[0.95rem] font-semibold text-on-surface">Thread</p> 70 70 <p class="m-0 text-[0.74rem] uppercase tracking-[0.12em] text-on-surface-variant">Nested replies</p> ··· 175 175 176 176 function SkeletonThreadCard() { 177 177 return ( 178 - <div class="rounded-[1.4rem] bg-white/3 p-5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 178 + <div class="rounded-3xl bg-white/3 p-5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 179 179 <div class="flex gap-3"> 180 180 <div class="skeleton-block h-11 w-11 rounded-full" /> 181 181 <div class="min-w-0 flex-1">
+27 -25
src/components/shared/ErrorToast.tsx
··· 1 - import type { Accessor } from "solid-js"; 1 + import { Show } from "solid-js"; 2 2 import { Motion, Presence } from "solid-motionone"; 3 3 4 - type ErrorToastProps = { message: Accessor<string | null>; onDismiss: () => void }; 4 + type ErrorToastProps = { message: string | null; onDismiss: () => void }; 5 5 6 6 export function ErrorToast(props: ErrorToastProps) { 7 7 return ( 8 8 <Presence> 9 - {props.message() && ( 10 - <Motion.div 11 - role="alert" 12 - aria-live="assertive" 13 - class="fixed bottom-6 left-1/2 grid w-max max-w-[min(30rem,calc(100vw-2rem))] -translate-x-1/2 grid-cols-[auto_1fr_auto] items-center gap-3 rounded-full bg-error-surface px-4 py-3 text-on-surface shadow-[0_24px_40px_rgba(125,175,255,0.05)] backdrop-blur-[20px] max-sm:w-[calc(100vw-1.5rem)]" 14 - initial={{ opacity: 0, y: 20, scale: 0.96 }} 15 - animate={{ opacity: 1, y: 0, scale: 1 }} 16 - exit={{ opacity: 0, y: 16, scale: 0.94 }} 17 - transition={{ duration: 0.2 }}> 18 - <span class="flex items-center text-error" aria-hidden="true"> 19 - <i class="i-ri-error-warning-line" /> 20 - </span> 21 - <p class="m-0 text-[0.875rem] text-on-surface">{props.message()}</p> 22 - <button 23 - type="button" 24 - class="cursor-pointer rounded-full border-0 bg-transparent p-[0.35rem] text-inherit hover:bg-surface-bright" 25 - onClick={props.onDismiss}> 26 - <span class="flex items-center" aria-hidden="true"> 27 - <i class="i-ri-close-line" /> 9 + <Show when={props.message}> 10 + {message => ( 11 + <Motion.div 12 + role="alert" 13 + aria-live="assertive" 14 + class="fixed bottom-6 left-1/2 grid w-max max-w-[min(30rem,calc(100vw-2rem))] -translate-x-1/2 grid-cols-[auto_1fr_auto] items-center gap-3 rounded-full bg-error-surface px-4 py-3 text-on-surface shadow-[0_24px_40px_rgba(125,175,255,0.05)] backdrop-blur-[20px] max-sm:w-[calc(100vw-1.5rem)]" 15 + initial={{ opacity: 0, y: 20, scale: 0.96 }} 16 + animate={{ opacity: 1, y: 0, scale: 1 }} 17 + exit={{ opacity: 0, y: 16, scale: 0.94 }} 18 + transition={{ duration: 0.2 }}> 19 + <span class="flex items-center text-error" aria-hidden="true"> 20 + <i class="i-ri-error-warning-line" /> 28 21 </span> 29 - <span class="sr-only">Dismiss error</span> 30 - </button> 31 - </Motion.div> 32 - )} 22 + <p class="m-0 text-[0.875rem] text-on-surface">{message()}</p> 23 + <button 24 + type="button" 25 + class="cursor-pointer rounded-full border-0 bg-transparent p-[0.35rem] text-inherit hover:bg-surface-bright" 26 + onClick={props.onDismiss}> 27 + <span class="flex items-center" aria-hidden="true"> 28 + <i class="i-ri-close-line" /> 29 + </span> 30 + <span class="sr-only">Dismiss error</span> 31 + </button> 32 + </Motion.div> 33 + )} 34 + </Show> 33 35 </Presence> 34 36 ); 35 37 }
+147
src/router.tsx
··· 1 + import { HashRouter, Navigate, Route, type RouteSectionProps, useLocation } from "@solidjs/router"; 2 + import { type Component, createEffect, type JSX, Show } from "solid-js"; 3 + import type { ActiveSession } from "./lib/types"; 4 + 5 + type AppRouterProps = { 6 + bootstrapping: boolean; 7 + hasSession: boolean; 8 + onLocationChange?: () => void; 9 + renderAuth: () => JSX.Element; 10 + renderShell: Component<{ children: JSX.Element }>; 11 + renderTimeline: (session: ActiveSession) => JSX.Element; 12 + session: ActiveSession | null; 13 + }; 14 + 15 + export function AppRouter(props: AppRouterProps) { 16 + const RouterFrame: Component<RouteSectionProps> = (routeProps) => { 17 + const location = useLocation(); 18 + let previousPath = location.pathname; 19 + 20 + createEffect(() => { 21 + const nextPath = location.pathname; 22 + if (nextPath !== previousPath) { 23 + props.onLocationChange?.(); 24 + previousPath = nextPath; 25 + } 26 + }); 27 + 28 + return <props.renderShell>{routeProps.children}</props.renderShell>; 29 + }; 30 + 31 + const IndexRoute = () => ( 32 + <Show when={!props.bootstrapping} fallback={<RouteLoadingState />}> 33 + <Navigate href={props.hasSession ? "/timeline" : "/auth"} /> 34 + </Show> 35 + ); 36 + 37 + const AuthRoute = () => ( 38 + <PublicOnlyRoute bootstrapping={props.bootstrapping} when={!props.hasSession} redirectHref="/timeline"> 39 + {props.renderAuth()} 40 + </PublicOnlyRoute> 41 + ); 42 + 43 + const TimelineRoute = () => ( 44 + <ProtectedRouteView bootstrapping={props.bootstrapping} session={props.session}> 45 + {(session) => props.renderTimeline(session)} 46 + </ProtectedRouteView> 47 + ); 48 + 49 + const SearchRoute = () => ( 50 + <ProtectedRouteView bootstrapping={props.bootstrapping} session={props.session}> 51 + {() => ( 52 + <FeaturePlaceholder 53 + eyebrow="Search" 54 + title="Local search is on deck." 55 + description="Keyword, semantic, and hybrid search routes are wired now. This view stays behind auth until the indexed search workflow lands." /> 56 + )} 57 + </ProtectedRouteView> 58 + ); 59 + 60 + const NotificationsRoute = () => ( 61 + <ProtectedRouteView bootstrapping={props.bootstrapping} session={props.session}> 62 + {() => ( 63 + <FeaturePlaceholder 64 + eyebrow="Notifications" 65 + title="Notification routing is gated." 66 + description="The notifications surface can now be added as an authenticated route without changing the shell again." /> 67 + )} 68 + </ProtectedRouteView> 69 + ); 70 + 71 + const ExplorerRoute = () => ( 72 + <ProtectedRouteView bootstrapping={props.bootstrapping} session={props.session}> 73 + {() => ( 74 + <FeaturePlaceholder 75 + eyebrow="AT Explorer" 76 + title="Explorer routing is ready." 77 + description="Deep-linked explorer screens can now mount as protected routes once the record and repository views are implemented." /> 78 + )} 79 + </ProtectedRouteView> 80 + ); 81 + 82 + const NotFoundRoute = () => ( 83 + <Show when={!props.bootstrapping} fallback={<RouteLoadingState />}> 84 + <Navigate href={props.hasSession ? "/timeline" : "/auth"} /> 85 + </Show> 86 + ); 87 + 88 + return ( 89 + <HashRouter root={RouterFrame}> 90 + <Route path="/" component={IndexRoute} /> 91 + <Route path="/auth" component={AuthRoute} /> 92 + <Route path="/timeline" component={TimelineRoute} /> 93 + <Route path="/search" component={SearchRoute} /> 94 + <Route path="/notifications" component={NotificationsRoute} /> 95 + <Route path="/explorer" component={ExplorerRoute} /> 96 + <Route path="*404" component={NotFoundRoute} /> 97 + </HashRouter> 98 + ); 99 + } 100 + 101 + function PublicOnlyRoute( 102 + props: { bootstrapping: boolean; when: boolean; redirectHref: string; children: JSX.Element }, 103 + ) { 104 + return ( 105 + <Show when={props.when || props.bootstrapping} fallback={<Navigate href={props.redirectHref} />}> 106 + {props.children} 107 + </Show> 108 + ); 109 + } 110 + 111 + function ProtectedRouteView( 112 + props: { bootstrapping: boolean; session: ActiveSession | null; children: (session: ActiveSession) => JSX.Element }, 113 + ) { 114 + return ( 115 + <Show when={!props.bootstrapping} fallback={<RouteLoadingState />}> 116 + <Show when={props.session} keyed fallback={<Navigate href="/auth" />}> 117 + {(session) => props.children(session)} 118 + </Show> 119 + </Show> 120 + ); 121 + } 122 + 123 + function RouteLoadingState() { 124 + return ( 125 + <div class="grid min-h-168 place-items-center rounded-4xl bg-white/2 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)]"> 126 + <div class="grid gap-3 text-center"> 127 + <p class="overline-copy text-sm text-on-surface-variant">Loading</p> 128 + <p class="m-0 text-base text-on-surface">Restoring your workspace.</p> 129 + </div> 130 + </div> 131 + ); 132 + } 133 + 134 + function FeaturePlaceholder(props: { description: string; eyebrow: string; title: string }) { 135 + return ( 136 + <article class="grid min-h-168 content-start gap-8 rounded-4xl bg-[linear-gradient(160deg,rgba(255,255,255,0.03),rgba(255,255,255,0.015))] p-8 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.035)]"> 137 + <div class="flex items-baseline justify-between gap-4"> 138 + <p class="overline-copy text-sm text-primary">{props.eyebrow}</p> 139 + <p class="overline-copy text-xs text-on-surface-variant">Authenticated route</p> 140 + </div> 141 + <div class="grid max-w-xl gap-4"> 142 + <h1 class="m-0 text-[clamp(2.6rem,5vw,4.3rem)] tracking-tighter text-on-surface">{props.title}</h1> 143 + <p class="m-0 max-w-136 text-base leading-7 text-on-secondary-container">{props.description}</p> 144 + </div> 145 + </article> 146 + ); 147 + }